AWSTemplateFormatVersion: '2010-09-09'
Description: >
  Old AMI Cleanup Remediator.
  Purpose-specific, opt-in remediation module for identifying and optionally deregistering
  old self-owned AMIs. Optionally deletes backing EBS snapshots only when explicitly enabled
  and only when those snapshots are not shared by other self-owned AMIs.

Parameters:
  ProjectName:
    Type: String
    Default: old-ami-cleanup-remediator
    Description: Base name used for created resources.

  TargetRegionsCsv:
    Type: String
    Default: all-enabled
    Description: Comma-separated AWS Regions to scan, or all-enabled to scan all enabled Regions.

  ExcludedRegionsCsv:
    Type: String
    Default: ap-northeast-3
    Description: Comma-separated AWS Regions to exclude from scanning/remediation.

  DryRun:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: When true, only report what would be changed. When false, perform eligible actions.

  MinimumAmiAgeDays:
    Type: Number
    Default: 180
    Description: Only AMIs older than this many days are considered candidates.

  RequireApprovalTag:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: >
      When true, only deregister/delete AMIs tagged with ApprovalTagKey=ApprovalTagValue.
      For large backlog cleanup, set false only after reviewing dry-run results and keeping exclusions enabled.

  ApprovalTagKey:
    Type: String
    Default: CostOptApproved
    Description: Tag key required when RequireApprovalTag is true.

  ApprovalTagValue:
    Type: String
    Default: 'true'
    Description: Tag value required when RequireApprovalTag is true.

  IgnoreTagKeysCsv:
    Type: String
    Default: CostOptIgnore,DoNotDelete
    Description: Comma-separated tag keys that prevent action when present, regardless of value.

  IgnoreTagPairsCsv:
    Type: String
    Default: CostOptIgnore=true,DoNotDelete=true
    Description: Comma-separated key=value tag pairs that prevent action when present.

  SkipAwsBackupAmis:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: Skip AMIs that appear to be AWS Backup managed based on name, description, or tags.

  SkipDlmAmis:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: Skip AMIs that appear to be Data Lifecycle Manager managed based on name, description, or tags.

  SkipThirdPartyBackupAmis:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: Skip AMIs that appear to be managed by third-party backup products.

  ThirdPartyBackupMarkersCsv:
    Type: String
    Default: Clumio,Veeam,Commvault,Rubrik,Druva,Cohesity,BackupID,BackupAssetID,TaskID,OrgID,Vendor=Clumio,OperationName=Clumio Backup,OperationType=EC2 Backup
    Description: Comma-separated text markers checked against AMI name, description, and tags when SkipThirdPartyBackupAmis is true.

  DeleteAssociatedSnapshots:
    Type: String
    Default: 'false'
    AllowedValues: ['true', 'false']
    Description: >
      When true, delete backing EBS snapshots after deregistering eligible AMIs, but only when
      those snapshots are not used by any other self-owned AMI. Default false for safety.

  MaxAmisPerRun:
    Type: Number
    Default: 50
    Description: Safety limit. 0 means no limit. Recommended batch cleanup value is 25 to 100.

  ContinueOnAccessDenied:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: Continue scanning other Regions/resources when blocked by IAM/SCP.

  ScheduleEnabled:
    Type: String
    Default: 'false'
    AllowedValues: ['true', 'false']
    Description: Whether to create an enabled EventBridge schedule for recurring runs.

  ScheduleExpression:
    Type: String
    Default: rate(30 days)
    Description: EventBridge schedule expression used when ScheduleEnabled is true.

Conditions:
  IsScheduleEnabled: !Equals [!Ref ScheduleEnabled, 'true']

Resources:
  RemediatorRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub '${ProjectName}-${AWS::AccountId}-${AWS::Region}-role'
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: OldAmiCleanupPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Sid: DiscoverRegions
                Effect: Allow
                Action:
                  - ec2:DescribeRegions
                Resource: '*'
              - Sid: ReadAmisSnapshotsAndTags
                Effect: Allow
                Action:
                  - ec2:DescribeImages
                  - ec2:DescribeSnapshots
                  - ec2:DescribeTags
                Resource: '*'
              - Sid: DeregisterAmis
                Effect: Allow
                Action:
                  - ec2:DeregisterImage
                Resource: '*'
              - Sid: DeleteBackingSnapshots
                Effect: Allow
                Action:
                  - ec2:DeleteSnapshot
                Resource: '*'

  RemediatorFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Ref ProjectName
      Description: Purpose-specific remediation Lambda for deregistering eligible old AMIs.
      Runtime: python3.12
      Handler: index.handler
      Timeout: 900
      MemorySize: 512
      Role: !GetAtt RemediatorRole.Arn
      Environment:
        Variables:
          TARGET_REGIONS_CSV: !Ref TargetRegionsCsv
          EXCLUDED_REGIONS_CSV: !Ref ExcludedRegionsCsv
          DRY_RUN: !Ref DryRun
          MINIMUM_AMI_AGE_DAYS: !Ref MinimumAmiAgeDays
          REQUIRE_APPROVAL_TAG: !Ref RequireApprovalTag
          APPROVAL_TAG_KEY: !Ref ApprovalTagKey
          APPROVAL_TAG_VALUE: !Ref ApprovalTagValue
          IGNORE_TAG_KEYS_CSV: !Ref IgnoreTagKeysCsv
          IGNORE_TAG_PAIRS_CSV: !Ref IgnoreTagPairsCsv
          SKIP_AWS_BACKUP_AMIS: !Ref SkipAwsBackupAmis
          SKIP_DLM_AMIS: !Ref SkipDlmAmis
          SKIP_THIRD_PARTY_BACKUP_AMIS: !Ref SkipThirdPartyBackupAmis
          THIRD_PARTY_BACKUP_MARKERS_CSV: !Ref ThirdPartyBackupMarkersCsv
          DELETE_ASSOCIATED_SNAPSHOTS: !Ref DeleteAssociatedSnapshots
          MAX_AMIS_PER_RUN: !Ref MaxAmisPerRun
          CONTINUE_ON_ACCESS_DENIED: !Ref ContinueOnAccessDenied
      Code:
        ZipFile: |
          import json
          import os
          import traceback
          from collections import defaultdict
          from datetime import datetime, timezone

          import boto3
          from botocore.exceptions import ClientError

          def utc_now():
              return datetime.now(timezone.utc)

          def utc_now_iso():
              return utc_now().isoformat()

          def env_bool(name, default=False):
              value = os.environ.get(name, str(default)).strip().lower()
              return value in ('1', 'true', 'yes', 'y')

          def env_int(name, default=0):
              try:
                  return int(os.environ.get(name, str(default)).strip())
              except Exception:
                  return default

          def split_csv(value):
              if value is None:
                  return []
              return [item.strip() for item in str(value).split(',') if item.strip()]

          def parse_tag_pairs(value):
              pairs = {}
              for item in split_csv(value):
                  if '=' not in item:
                      continue
                  key, val = item.split('=', 1)
                  key = key.strip()
                  val = val.strip()
                  if key:
                      pairs[key] = val
              return pairs

          def tags_to_dict(tags):
              result = {}
              for tag in tags or []:
                  key = tag.get('Key')
                  if key:
                      result[key] = tag.get('Value', '')
              return result

          def parse_creation_date(value):
              if not value:
                  return None
              try:
                  # EC2 CreationDate looks like 2024-01-01T12:34:56.000Z
                  if value.endswith('Z'):
                      value = value[:-1] + '+00:00'
                  return datetime.fromisoformat(value)
              except Exception:
                  return None

          def ami_age_days(image):
              created = parse_creation_date(image.get('CreationDate'))
              if not created:
                  return None
              if created.tzinfo is None:
                  created = created.replace(tzinfo=timezone.utc)
              return (utc_now() - created).days

          def get_enabled_regions(excluded_regions):
              requested = os.environ.get('TARGET_REGIONS_CSV', 'all-enabled').strip()
              if requested and requested.lower() != 'all-enabled':
                  regions = split_csv(requested)
              else:
                  ec2 = boto3.client('ec2', region_name=os.environ.get('AWS_REGION', 'us-east-1'))
                  response = ec2.describe_regions(AllRegions=False)
                  regions = sorted([r['RegionName'] for r in response.get('Regions', [])])
              return [r for r in regions if r not in excluded_regions]

          def ami_snapshot_ids(image):
              ids = []
              for mapping in image.get('BlockDeviceMappings', []):
                  ebs = mapping.get('Ebs') or {}
                  sid = ebs.get('SnapshotId')
                  if sid:
                      ids.append(sid)
              return ids

          def searchable_image_text(image, tag_map):
              parts = [
                  str(image.get('Name') or '').lower(),
                  str(image.get('Description') or '').lower(),
                  str(image.get('ImageLocation') or '').lower()
              ]
              for key, value in tag_map.items():
                  parts.append((str(key) + '=' + str(value)).lower())
                  parts.append(str(key).lower())
                  parts.append(str(value).lower())
              return ' '.join(parts)

          def has_ignore_tag(tag_map, ignore_tag_keys, ignore_tag_pairs):
              for key in ignore_tag_keys:
                  if key in tag_map:
                      return True, 'ignore_tag_key=' + key
              for key, value in ignore_tag_pairs.items():
                  if tag_map.get(key) == value:
                      return True, 'ignore_tag_pair=' + key + '=' + value
              return False, ''

          def has_approval_tag(tag_map, settings):
              if not settings['require_approval_tag']:
                  return True
              return tag_map.get(settings['approval_tag_key']) == settings['approval_tag_value']

          def appears_aws_backup_managed(image, tag_map):
              text = searchable_image_text(image, tag_map)
              markers = ['awsbackup', 'aws backup', 'aws:backup', 'backup plan', 'recovery point']
              return any(marker in text for marker in markers)

          def appears_dlm_managed(image, tag_map):
              text = searchable_image_text(image, tag_map)
              markers = ['aws:dlm', 'dlm', 'data lifecycle manager']
              return any(marker in text for marker in markers)

          def appears_third_party_backup_managed(image, tag_map, markers):
              text = searchable_image_text(image, tag_map)
              for marker in markers:
                  if marker.lower() in text:
                      return True
              return False

          def build_snapshot_usage(images):
              usage = defaultdict(list)
              for image in images:
                  image_id = image.get('ImageId')
                  for sid in ami_snapshot_ids(image):
                      usage[sid].append(image_id)
              return dict(usage)

          def scan_region(region, settings):
              ec2 = boto3.client('ec2', region_name=region)
              result = {
                  'region': region,
                  'evaluated': 0,
                  'old_amis': 0,
                  'would_deregister': 0,
                  'deregistered': 0,
                  'would_delete_snapshots': 0,
                  'deleted_snapshots_count': 0,
                  'skipped_too_new': 0,
                  'skipped_aws_backup_managed': 0,
                  'skipped_dlm_managed': 0,
                  'skipped_third_party_backup_managed': 0,
                  'skipped_missing_approval_tag': 0,
                  'skipped_ignore_tag': 0,
                  'skipped_snapshot_used_by_other_ami': 0,
                  'access_denied': 0,
                  'failed': 0,
                  'failures': [],
                  'candidates': [],
                  'deregistered_amis': [],
                  'deleted_snapshots': []
              }

              try:
                  paginator = ec2.get_paginator('describe_images')
                  pages = paginator.paginate(Owners=['self'])
                  images = []
                  for page in pages:
                      images.extend(page.get('Images', []))
              except ClientError as e:
                  code = e.response.get('Error', {}).get('Code', 'Unknown')
                  message = e.response.get('Error', {}).get('Message', str(e))
                  result['failures'].append({'operation': 'DescribeImages', 'error_code': code, 'error_message': message})
                  if code in ('AccessDenied', 'AccessDeniedException', 'UnauthorizedOperation') or 'explicit deny' in message.lower():
                      result['access_denied'] += 1
                  else:
                      result['failed'] += 1
                  if not settings['continue_on_access_denied']:
                      raise
                  return result

              snapshot_usage = build_snapshot_usage(images)

              for image in images:
                  if settings['max_amis_per_run'] and (result['would_deregister'] + result['deregistered']) >= settings['max_amis_per_run']:
                      result['stopped_due_to_max_amis_per_run'] = True
                      return result

                  result['evaluated'] += 1
                  image_id = image.get('ImageId')
                  name = image.get('Name')
                  age_days = ami_age_days(image)
                  tag_map = tags_to_dict(image.get('Tags', []))
                  snapshots = ami_snapshot_ids(image)

                  if age_days is None or age_days < settings['minimum_ami_age_days']:
                      result['skipped_too_new'] += 1
                      continue

                  result['old_amis'] += 1

                  if settings['skip_aws_backup_amis'] and appears_aws_backup_managed(image, tag_map):
                      result['skipped_aws_backup_managed'] += 1
                      continue

                  if settings['skip_dlm_amis'] and appears_dlm_managed(image, tag_map):
                      result['skipped_dlm_managed'] += 1
                      continue

                  if settings['skip_third_party_backup_amis'] and appears_third_party_backup_managed(image, tag_map, settings['third_party_backup_markers']):
                      result['skipped_third_party_backup_managed'] += 1
                      continue

                  ignore, ignore_reason = has_ignore_tag(tag_map, settings['ignore_tag_keys'], settings['ignore_tag_pairs'])
                  if ignore:
                      result['skipped_ignore_tag'] += 1
                      result['candidates'].append({
                          'image_id': image_id,
                          'name': name,
                          'age_days': age_days,
                          'action': 'skipped_ignore_tag',
                          'reason': ignore_reason,
                          'tags': tag_map
                      })
                      continue

                  if not has_approval_tag(tag_map, settings):
                      result['skipped_missing_approval_tag'] += 1
                      result['candidates'].append({
                          'image_id': image_id,
                          'name': name,
                          'age_days': age_days,
                          'action': 'skipped_missing_approval_tag',
                          'required_tag': settings['approval_tag_key'] + '=' + settings['approval_tag_value'],
                          'snapshot_ids': snapshots,
                          'tags': tag_map
                      })
                      continue

                  snapshots_safe_to_delete = []
                  snapshots_used_elsewhere = []
                  for sid in snapshots:
                      users = snapshot_usage.get(sid, [])
                      other_users = [u for u in users if u != image_id]
                      if other_users:
                          snapshots_used_elsewhere.append({'snapshot_id': sid, 'other_ami_ids': other_users})
                      else:
                          snapshots_safe_to_delete.append(sid)

                  candidate = {
                      'image_id': image_id,
                      'name': name,
                      'age_days': age_days,
                      'creation_date': image.get('CreationDate'),
                      'snapshot_ids': snapshots,
                      'snapshots_safe_to_delete': snapshots_safe_to_delete,
                      'snapshots_used_by_other_amis': snapshots_used_elsewhere,
                      'tags': tag_map,
                      'action': 'would_deregister' if settings['dry_run'] else 'deregister'
                  }

                  if settings['dry_run']:
                      result['would_deregister'] += 1
                      if settings['delete_associated_snapshots']:
                          result['would_delete_snapshots'] += len(snapshots_safe_to_delete)
                          result['skipped_snapshot_used_by_other_ami'] += len(snapshots_used_elsewhere)
                      result['candidates'].append(candidate)
                      continue

                  try:
                      ec2.deregister_image(ImageId=image_id)
                      result['deregistered'] += 1
                      result['deregistered_amis'].append(candidate)
                  except ClientError as e:
                      code = e.response.get('Error', {}).get('Code', 'Unknown')
                      message = e.response.get('Error', {}).get('Message', str(e))
                      result['failures'].append({'image_id': image_id, 'operation': 'DeregisterImage', 'error_code': code, 'error_message': message})
                      if code in ('AccessDenied', 'AccessDeniedException', 'UnauthorizedOperation') or 'explicit deny' in message.lower():
                          result['access_denied'] += 1
                      else:
                          result['failed'] += 1
                      if not settings['continue_on_access_denied']:
                          raise
                      continue

                  if settings['delete_associated_snapshots']:
                      for sid in snapshots_safe_to_delete:
                          try:
                              ec2.delete_snapshot(SnapshotId=sid)
                              result['deleted_snapshots_count'] += 1
                              result['deleted_snapshots'].append({'image_id': image_id, 'snapshot_id': sid})
                          except ClientError as e:
                              code = e.response.get('Error', {}).get('Code', 'Unknown')
                              message = e.response.get('Error', {}).get('Message', str(e))
                              result['failures'].append({'snapshot_id': sid, 'operation': 'DeleteSnapshot', 'error_code': code, 'error_message': message})
                              if code in ('AccessDenied', 'AccessDeniedException', 'UnauthorizedOperation') or 'explicit deny' in message.lower():
                                  result['access_denied'] += 1
                              else:
                                  result['failed'] += 1
                              if not settings['continue_on_access_denied']:
                                  raise
                      result['skipped_snapshot_used_by_other_ami'] += len(snapshots_used_elsewhere)

              return result

          def handler(event, context):
              settings = {
                  'excluded_regions': split_csv(os.environ.get('EXCLUDED_REGIONS_CSV', '')),
                  'dry_run': env_bool('DRY_RUN', True),
                  'minimum_ami_age_days': env_int('MINIMUM_AMI_AGE_DAYS', 180),
                  'require_approval_tag': env_bool('REQUIRE_APPROVAL_TAG', True),
                  'approval_tag_key': os.environ.get('APPROVAL_TAG_KEY', 'CostOptApproved'),
                  'approval_tag_value': os.environ.get('APPROVAL_TAG_VALUE', 'true'),
                  'ignore_tag_keys': split_csv(os.environ.get('IGNORE_TAG_KEYS_CSV', '')),
                  'ignore_tag_pairs': parse_tag_pairs(os.environ.get('IGNORE_TAG_PAIRS_CSV', '')),
                  'skip_aws_backup_amis': env_bool('SKIP_AWS_BACKUP_AMIS', True),
                  'skip_dlm_amis': env_bool('SKIP_DLM_AMIS', True),
                  'skip_third_party_backup_amis': env_bool('SKIP_THIRD_PARTY_BACKUP_AMIS', True),
                  'third_party_backup_markers': split_csv(os.environ.get('THIRD_PARTY_BACKUP_MARKERS_CSV', '')),
                  'delete_associated_snapshots': env_bool('DELETE_ASSOCIATED_SNAPSHOTS', False),
                  'max_amis_per_run': env_int('MAX_AMIS_PER_RUN', 50),
                  'continue_on_access_denied': env_bool('CONTINUE_ON_ACCESS_DENIED', True),
              }

              if isinstance(event, dict):
                  if 'dry_run' in event:
                      settings['dry_run'] = bool(event['dry_run'])
                  if 'require_approval_tag' in event:
                      settings['require_approval_tag'] = bool(event['require_approval_tag'])
                  if 'minimum_ami_age_days' in event:
                      settings['minimum_ami_age_days'] = int(event['minimum_ami_age_days'])
                  if 'delete_associated_snapshots' in event:
                      settings['delete_associated_snapshots'] = bool(event['delete_associated_snapshots'])
                  if 'max_amis_per_run' in event:
                      settings['max_amis_per_run'] = int(event['max_amis_per_run'])
                  if 'target_regions' in event and event['target_regions']:
                      os.environ['TARGET_REGIONS_CSV'] = ','.join(event['target_regions'])
                  if 'excluded_regions' in event and event['excluded_regions']:
                      settings['excluded_regions'] = list(event['excluded_regions'])

              summary = {
                  'started_at': utc_now_iso(),
                  'dry_run': settings['dry_run'],
                  'minimum_ami_age_days': settings['minimum_ami_age_days'],
                  'require_approval_tag': settings['require_approval_tag'],
                  'approval_tag': settings['approval_tag_key'] + '=' + settings['approval_tag_value'],
                  'delete_associated_snapshots': settings['delete_associated_snapshots'],
                  'skip_aws_backup_amis': settings['skip_aws_backup_amis'],
                  'skip_dlm_amis': settings['skip_dlm_amis'],
                  'skip_third_party_backup_amis': settings['skip_third_party_backup_amis'],
                  'third_party_backup_markers': settings['third_party_backup_markers'],
                  'ignore_tag_keys': settings['ignore_tag_keys'],
                  'ignore_tag_pairs': settings['ignore_tag_pairs'],
                  'max_amis_per_run': settings['max_amis_per_run'],
                  'excluded_regions': settings['excluded_regions'],
                  'regions': [],
                  'totals': {
                      'evaluated': 0,
                      'old_amis': 0,
                      'would_deregister': 0,
                      'deregistered': 0,
                      'would_delete_snapshots': 0,
                      'deleted_snapshots_count': 0,
                      'skipped_too_new': 0,
                      'skipped_aws_backup_managed': 0,
                      'skipped_dlm_managed': 0,
                      'skipped_third_party_backup_managed': 0,
                      'skipped_missing_approval_tag': 0,
                      'skipped_ignore_tag': 0,
                      'skipped_snapshot_used_by_other_ami': 0,
                      'access_denied': 0,
                      'failed': 0
                  },
                  'region_results': []
              }

              try:
                  regions = get_enabled_regions(settings['excluded_regions'])
                  summary['regions'] = regions
                  for region in regions:
                      try:
                          region_result = scan_region(region, settings)
                      except Exception as e:
                          region_result = {
                              'region': region,
                              'evaluated': 0,
                              'old_amis': 0,
                              'would_deregister': 0,
                              'deregistered': 0,
                              'would_delete_snapshots': 0,
                              'deleted_snapshots_count': 0,
                              'skipped_too_new': 0,
                              'skipped_aws_backup_managed': 0,
                              'skipped_dlm_managed': 0,
                              'skipped_third_party_backup_managed': 0,
                              'skipped_missing_approval_tag': 0,
                              'skipped_ignore_tag': 0,
                              'skipped_snapshot_used_by_other_ami': 0,
                              'access_denied': 0,
                              'failed': 1,
                              'failures': [{'error_code': type(e).__name__, 'error_message': str(e), 'trace': traceback.format_exc()}],
                              'candidates': [],
                              'deregistered_amis': [],
                              'deleted_snapshots': []
                          }
                      summary['region_results'].append(region_result)
                      for key in summary['totals']:
                          summary['totals'][key] += int(region_result.get(key, 0) or 0)
              finally:
                  summary['ended_at'] = utc_now_iso()
                  print(json.dumps(summary, indent=2, default=str))
              return summary

  RemediatorScheduleRule:
    Type: AWS::Events::Rule
    Condition: IsScheduleEnabled
    Properties:
      Name: !Sub '${ProjectName}-schedule'
      Description: Scheduled run for Old AMI Cleanup Remediator.
      ScheduleExpression: !Ref ScheduleExpression
      State: ENABLED
      Targets:
        - Arn: !GetAtt RemediatorFunction.Arn
          Id: OldAmiCleanupRemediatorTarget
          Input: '{"source":"eventbridge-schedule"}'

  RemediatorSchedulePermission:
    Type: AWS::Lambda::Permission
    Condition: IsScheduleEnabled
    Properties:
      FunctionName: !Ref RemediatorFunction
      Action: lambda:InvokeFunction
      Principal: events.amazonaws.com
      SourceArn: !GetAtt RemediatorScheduleRule.Arn

Outputs:
  RemediatorFunctionName:
    Description: Lambda function name.
    Value: !Ref RemediatorFunction

  RemediatorFunctionArn:
    Description: Lambda function ARN.
    Value: !GetAtt RemediatorFunction.Arn

  DryRunSetting:
    Description: Current DryRun setting.
    Value: !Ref DryRun

  MinimumAmiAgeDaysSetting:
    Description: Current minimum AMI age setting.
    Value: !Ref MinimumAmiAgeDays

  RequireApprovalTagSetting:
    Description: Current RequireApprovalTag setting.
    Value: !Ref RequireApprovalTag

  DeleteAssociatedSnapshotsSetting:
    Description: Whether backing snapshots are deleted.
    Value: !Ref DeleteAssociatedSnapshots

  ManualDryRunExample:
    Description: Example manual dry-run invocation payload.
    Value: !Sub 'aws lambda invoke --function-name ${RemediatorFunction} --cli-binary-format raw-in-base64-out --payload ''{"dry_run":true}'' response.json'

  ManualBatchCleanupDryRunExample:
    Description: Example AMI batch cleanup dry-run payload.
    Value: !Sub 'aws lambda invoke --function-name ${RemediatorFunction} --cli-binary-format raw-in-base64-out --payload ''{"dry_run":true,"require_approval_tag":false,"minimum_ami_age_days":180,"delete_associated_snapshots":false,"max_amis_per_run":50}'' response.json'
